Skip to content

fix(perps-controller): correct significant-figure counting for sub-$1 HyperLiquid prices - #10052

Open
gomesalexandre wants to merge 2 commits into
MetaMask:mainfrom
gomesalexandre:fix_hyperliquid_significant_figures
Open

fix(perps-controller): correct significant-figure counting for sub-$1 HyperLiquid prices#10052
gomesalexandre wants to merge 2 commits into
MetaMask:mainfrom
gomesalexandre:fix_hyperliquid_significant_figures

Conversation

@gomesalexandre

@gomesalexandre gomesalexandre commented Sep 1, 2026

Copy link
Copy Markdown

Explanation

countSignificantFigures (packages/perps-controller/src/utils/significantFigures.ts) only stripped leading zeros from the integer part of a number, then added the full decimal-part length. For any HyperLiquid perp price under $1, the integer part is always "0", so its two/three leading fractional zeros got counted as significant figures too:

const trimmedInteger = integerPart.replace(/^-?0*/u, '') || '';
const effectiveIntegerLength = decimalPart ? trimmedInteger.length : ...;
return effectiveIntegerLength + decimalPart.length;

0.001234 — HyperLiquid's own canonical example of a valid perp price (4 significant figures) — was miscounted as 6. That fed into roundToSignificantFigures, whose allowedDecimalDigits = maxSigFigs - integerSigFigs degenerates into a fixed-decimal-places round when integerSigFigs is 0, needlessly re-rounding an already-valid price down to 0.00123 and destroying a real digit of precision.

The package already implements the correct rule elsewhere, and the two disagree. getPriceTick (orderCalculations.ts:330-347) computes HyperLiquid's precision rule correctly via Math.floor(Math.log10(price)) — magnitude-based, so it doesn't care whether the price is above or below 1. computeChaseQuotePrice calls getPriceTick for the correct tick size, then routes the resulting price through the broken formatHyperLiquidPricecountSignificantFigures path. For a sub-$1 book this is a real functional failure, not just a display nit:

$ node repro (HMSTR-shaped book, bestBid 0.000171 / bestAsk 0.000175, szDecimals 0)
tick (getPriceTick, correct):  0.000001
buyChase  (before fix): 0.00017   -- NOT > bestBid (0.000171) -- rests behind the whole queue
sellChase (before fix): 0.00017   -- NOT > bestBid            -- crosses the book; an ALO order gets rejected
buyChase  (after fix):  0.000172  -- > bestBid, correct
sellChase (after fix):  0.000174  -- between bestBid and bestAsk, correct

Fix

  • countSignificantFigures: strip leading zeros from the combined integer+decimal digit string, not just the integer part.
  • roundToSignificantFigures: switched from the ad-hoc maxSigFigs - integerSigFigs decimal budget to the same order-of-magnitude approach getPriceTick already uses correctly, so the package has one significant-figures rule instead of two independently-maintained (and disagreeing) copies.
  • Fixed significantFigures.test.ts's existing assertion pinning '0.001234' at 6 significant figures — that expectation was captured from the buggy implementation, not derived; the correct value is 4.
  • Added real coverage for the previously-untested < 1 branch across countSignificantFigures, hasExceededSignificantFigures, roundToSignificantFigures, and formatHyperLiquidPrice (which had zero unit tests before this PR), plus the computeChaseQuotePrice/getPriceTick sub-$1 regression above.

Receipts

Genuine red-before/green-after — reverted just the source fix and reran the new tests against the old logic:

FAIL formatHyperLiquidPrice.test.ts
  ✕ preserves full precision for 0.000463 (szDecimals=0): Expected "0.000463", Received "0.00046"
  ✕ preserves full precision for 0.000171 (szDecimals=0): Expected "0.000171", Received "0.00017"
  ✕ still rounds a sub-$1 price that genuinely exceeds 5 significant figures: Expected "0.001235", Received "0.00123"
FAIL orderCalculations.scale-ladder.test.ts
  ✕ rests strictly inside the spread for a sub-$1 book (HMSTR-shaped): Expected "0.000172", Received "0.00017"
Tests: 5 failed, 47 passed, 52 total

Restored the fix — same tests, plus the full package suite:

$ yarn jest --testPathPatterns='formatHyperLiquidPrice|orderCalculations.scale-ladder|significantFigures'
Tests: 68 passed, 68 total

$ yarn jest   (full perps-controller package)
Test Suites: 85 passed, 85 total
Tests:       40 skipped, 3697 passed, 3737 total

$ yarn lint:tsc   (monorepo-wide tsc --build)
$ echo $?
0

$ yarn eslint <changed files>
$ echo $?
0

$ yarn changelog:validate
$ echo $?
0

Risk / scope

Low. Two pure functions and their one downstream consumer (formatHyperLiquidPrice); no change to getPriceTick itself, which was already correct and is now the reference the other function follows.

Known limitation, unaffected by this PRcountSignificantFigures/roundToSignificantFigures are exported as general-purpose pure functions, and neither the old nor the new implementation correctly parses a raw exponential-notation string (e.g. "1.23e-7") if called directly with one — both mishandle it identically. This is unreachable via the actual price-formatting pipeline: formatHyperLiquidPrice bounds its input through toFixed(maxDecimalPlaces) (max 6 decimal places) before these functions ever see it, and (0.000001).toString() stays in decimal notation — JS only switches to exponential below 1e-6. Not fixing full scientific-notation support here since it's pre-existing, unregressed, and outside the price-formatting domain this package actually exercises.

Codex reviewed the diff adversarially but didn't return a verdict inside a reasonable time budget (~9 minutes of visible, genuine progress — not a stall); killed the process (targeting only its own PID) and substituted the two specific things it was mid-investigation on myself: the exponential-notation question above, and whether toFixed's IEEE-754 rounding behavior (e.g. (0.123455).toFixed(5) rounding down due to 0.123455 not being exactly representable) is a new regression — it isn't; toFixed was already used by the pre-fix code and this is an inherent floating-point property, not something this diff introduces.


Note

Low Risk
Pure math utilities in perps-controller with no auth or persistence changes; main effect is more accurate HyperLiquid price strings for sub-$1 assets and chase orders.

Overview
Fixes HyperLiquid price formatting for sub-$1 markets by correcting how significant figures are counted and rounded in significantFigures.ts.

countSignificantFigures now strips leading zeros from the full digit string (integer + fraction), so values like 0.001234 count as 4 sig figs instead of 6. roundToSignificantFigures no longer uses the broken integer-part decimal budget; it uses the same order-of-magnitude rule as getPriceTick, and returns prices unchanged when they already fit within the cap.

That stops formatHyperLiquidPrice from over-rounding valid limit/TP/SL/trigger prices and fixes post-only chase quotes on penny books: chase prices stay one tick inside the spread instead of resting at or through the touch (ALO rejections). Changelog and regression tests cover formatHyperLiquidPrice, chase ladder behavior, and the sig-fig helpers.

Reviewed by Cursor Bugbot for commit 7547eff. Bugbot is set up for automated code reviews on this repo. Configure here.

… HyperLiquid prices

countSignificantFigures only stripped leading zeros from the integer part of
a number, so every price under $1 (integer part '0') had its leading
fractional zeros counted as significant figures. 0.001234 (4 real
significant figures, HyperLiquid's own canonical valid-price example) was
miscounted as 6, which fed into roundToSignificantFigures and needlessly
re-rounded it down to 0.00123, destroying real precision.

The same package's getPriceTick (orderCalculations.ts) already implements
HyperLiquid's precision rule correctly via order-of-magnitude (log10), and
computeChaseQuotePrice routes its correctly-computed tick through the
broken formatHyperLiquidPrice -> countSignificantFigures path, producing a
real functional failure: a post-only chase order meant to rest one tick
inside the spread came back at or across the touch instead, for any
sub-$1 book.

Fix: count significant figures by stripping leading zeros across the
combined integer+decimal digit string (matching the true definition), and
reconcile roundToSignificantFigures's rounding with getPriceTick's
magnitude-based approach instead of maintaining two independently-wrong
rounding strategies.

Also fixes the existing significantFigures.test.ts assertion that pinned
0.001234's significant-figure count as 6 (captured from the buggy
implementation, not derived) - it should be 4.
@gomesalexandre
gomesalexandre marked this pull request as ready for review September 1, 2026 17:19
@gomesalexandre
gomesalexandre requested review from a team as code owners September 1, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant